<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>Chrome Compatibility Detector</title>
<script type="text/javascript" src="constants.js"></script>
<script>

// Maps tabId to DetectionResult.
var detectionResults = {};

// Maps tabId to RequestInfo.
var requestInfoMap = {};

var ICON_PATH = {
  normal: 'icon_normal.png',
  ok: 'icon_ok.png',
  warning: 'icon_warning.png',
  error: 'icon_error.png'
};

var DETECTION_COMPAT_STATUS_OF_PRIORITY = {
  normal: 0,
  ok: 1,
  warning: 2,
  error: 3
};

function getLocale() {
  var DEFAULT_LOCALE = 'en';
  var LOCALES = {'zh-cn': true, 'en': true};
  var locale = chrome.i18n.getMessage('@@ui_locale');
  if (locale)
    locale = locale.toLowerCase().replace('_', '-');
  if (!LOCALES[locale])
    locale = DEFAULT_LOCALE;
  return locale;
}

/**
 * @return {Object} a detector ID map
 */
function getDisabledDetectors() {
  var disabledDetectorsStr = window.localStorage.getItem(DISABLED_DETECTORS);
  if (!disabledDetectorsStr)
    disabledDetectorsStr = '';
  var disabledDetectorList = disabledDetectorsStr.split(SEPARATOR);
  var disabledDetectors = {};
  for (var i = 0, c = disabledDetectorList.length; i < c; ++i) {
    var id = disabledDetectorList[i];
    disabledDetectors[id] = true;
  }
  return disabledDetectors;
}

/**
 * @param {Object} disabledDetectors a detector ID map
 */
function setDisabledDetectors(disabledDetectors) {
  var disabledDetectorsStr = Object.keys(disabledDetectors).join(SEPARATOR);
  window.localStorage.setItem(DISABLED_DETECTORS, disabledDetectorsStr);
}

function setDetectionCompatStatus(tabId, status) {
  getDetectionResult(tabId).detectionCompatStatus = status;
}

function getDetectionCompatStatusOfPriority(tabId, status) {
  var currentStatus = getDetectionResult(tabId).detectionCompatStatus;
  return DETECTION_COMPAT_STATUS_OF_PRIORITY[currentStatus] >=
      DETECTION_COMPAT_STATUS_OF_PRIORITY[status] ?
      currentStatus : status;
}

function setBrowserAction(status, tabId) {
  log('setBrowserAction: status=' + status + ', tabId=' + tabId);
  status = getDetectionCompatStatusOfPriority(tabId, status);
  setDetectionCompatStatus(tabId, status);
  chrome.browserAction.setIcon({
    path: ICON_PATH[status],
    tabId: tabId
  });
  chrome.browserAction.setTitle({
    title: chrome.i18n.getMessage(status + 'TitleText'),
    tabId: tabId
  });
}

function DetectionResult(tabId) {
  this.tabId = tabId;

  this.totalProblems = 0;
  this.totalErrors = 0;
  this.totalWarnings = 0;

  this.problems = {};  // maps reason to Problem object
  this.annotatedReasons = {};  // maps reason to true

  // TODO: what is this?
  this.detected = false;

  this.showAdvanced = false;  // remember which tab to show

  this.browserActionStatus = 'ok';
  this.detectionCompatStatus = 'normal';
}

DetectionResult.prototype.addProblem = function(problem, reason) {
  this.problems[reason] = problem;
  this.totalProblems++;
  var severity = problem.severity;
  if (severity == 'error') {
    this.totalErrors++;
    if (this.browserActionStatus != severity) {
      this.browserActionStatus = severity;
      setBrowserAction(severity, this.tabId);
    }
  } else {
    this.totalWarnings++;
    if (this.browserActionStatus == 'ok') {
      this.browserActionStatus = severity;
      setBrowserAction(severity, this.tabId);
    }
  }
};

DetectionResult.prototype.getProblem = function(reason) {
  return this.problems[reason];
}

function Problem(severity, description) {
  this.severity = severity;
  this.description = description;
  this.occurrencesNumber = 0;
}

Problem.prototype.setOccurrencesNumber = function(number) {
  this.occurrencesNumber = number;
};

/**
 * @return {DetectionResult} the detection result, will NOT be null
 */
function getDetectionResult(tabId) {
  var result = detectionResults[tabId];
  if (!result) {
    result = new DetectionResult(tabId);
    detectionResults[tabId] = result;
  }
  return result;
}

function log(message) {
  window.console.log(message);
}

function getPopup() {
  var views = chrome.extension.getViews({type: 'popup'});
  log('views.length=' + views.length);
  if (views.length > 0)
    return views[0];
  return null;
}

/**
 * Add compatibility problem detection result to cache and show in popup page
 * if popup page is opened.
 * @param {Number} tabId
 * @param {Object} issue
 */
function addDetectedProblemToResult(tabId, issue) {
  var reason = issue.reason;
  // First show result if popup page is opened, consider all detection result
  // cache has shown on the popup page this time, so it can't add one problem
  // repeatedly.
  var popup = getPopup();
  if (popup) {
    popup.updateDetectionResult(tabId, reason, issue);
  }

  var detectionResult = getDetectionResult(tabId);

  // When switching to advanced tab the first time, the page will be reloaded,
  // and the last showAdvanced flag will be cleared. We want to show advanced
  // tab next time, so set the flag here.
  detectionResult.showAdvanced = true;

  var problem = detectionResult.getProblem(reason);
  var isNewProblem = false;
  var severity;
  if (!problem) {
    isNewProblem = true;
    severity = issue.severity;
    problem = new Problem(severity, issue.description);
    detectionResult.addProblem(problem, reason);
  }
  problem.setOccurrencesNumber(issue.occurrencesNumber);
  if (popup && isNewProblem) {
    popup.updateSummary(severity);
  }
}

function stopAddingDetectedProblem(tabId, totalProblems) {
  var detectionResult = getDetectionResult(tabId);
  detectionResult.detected = true;

  var popup = getPopup();
  // If totalProblems is 0, show no compatibility problems found infomation.
  if (totalProblems == 0 && popup) {
    popup.showNoProblemResult();
  } else {
    if (popup)
      popup.setDetectionFinishedMessage();
    annotateAllIssues(tabId);
  }
}

function annotateAllIssues(tabId) {
  var detectionResult = getDetectionResult(tabId);
  var reasons = Object.keys(detectionResult.problems);
  reasons.forEach(function(reason) {
    detectionResult.annotatedReasons[reason] = true;
  });
  annotate(reasons);
}

/**
 * Annotate problems on the target node in the page
 * @param {Array} reasons
 */
function annotate(reasons) {
  chrome.tabs.getSelected(null, function(tab) {
    var tabId = tab.id;
    chrome.tabs.sendRequest(tabId, { type: 'AnnotationOff' }, function() {
      if (reasons.length) {
        chrome.tabs.sendRequest(tabId, {
          type: 'AnnotationOn',
          // TODO: rename issusId to reasons
          issusId: reasons
        }, function() {
          var popup = getPopup();
          if (popup) {
            popup.restoreAnnotationCheck();
          }
        });
      }
    });
  });
}

/**
 * This class stores cross-origin CSS requests for a tab.
 * It also stores other resources used by a tab, like the timer ID.
 */
function RequestInfo() {
  this.requestResultMap = {};  // map url to RequestResult
  this.timerId = null;
}

function RequestResult(xhr) {
  this.xhr = xhr;  // the XMLHttpRequest object
  this.cssText = null;
  this.finished = false;
}

/**
 * Fetch stylesheets from other sites. Page script is not allowed to do this,
 * so we do it in extension's background page.
 * @param tabId
 * @param {Object} urlMap contains urls of multiple stylesheets
 */
function getCrossOriginCSS(tabId, urlMap) {
  // Aborts pending requests if any.
  abortXHR(tabId);

  var requestInfo = new RequestInfo();
  requestInfoMap[tabId] = requestInfo;

  // Fetch all stylesheets in parallel.
  for (var url in urlMap) {
    var xhr = new XMLHttpRequest();
    requestInfo.requestResultMap[url] = new RequestResult(xhr);
    xhr.open("GET", url, true);
    xhr.setRequestHeader('Accept', 'text/css,*/*;q=0.1');
    xhr.url_ = url;
    xhr.tabId_ = tabId;
    xhr.onreadystatechange = function() {
      if (this.readyState == 4)
        crossOriginCSSCallback(this);
    };
    xhr.send();
  }

  // Set a timeout limit for all requests.
  // TODO: this might be too small when running on VM.
  var AJAX_FETCH_TIMEOUT_MS = 10 * 1000;
  requestInfo.timerId = setTimeout(function() {
    crossOrginOperationFinished(tabId);
  }, AJAX_FETCH_TIMEOUT_MS);
}

function crossOriginCSSCallback(xhr) {
  var requestInfo = requestInfoMap[xhr.tabId_];
  if (!requestInfo)
    return;
  var requestResult = requestInfo.requestResultMap[xhr.url_];
  if (xhr.status == 200) {
    // Check whether the Content-Type is 'text/css' or 'text/css;...'. If not,
    // ignore the response to prevent cross-origin CSS attack. Refer to:
    // Protecting Browsers from Cross-Origin CSS Attacks
    // http://websec.sv.cmu.edu/css/css.pdf
    var CONTENT_TYPE_CHECKER = /^text\/css\s*(;.*)?$/i;
    var contentType = xhr.getResponseHeader('Content-Type');
    if (contentType && CONTENT_TYPE_CHECKER.test(contentType)) {
      requestResult.cssText = processCSSText(xhr.responseText);
    }
  }
  requestResult.finished = true;
  requestResult.xhr = null;
  if (isAllRequestFinished(requestInfo.requestResultMap)) {
    crossOrginOperationFinished(xhr.tabId_);
  }
}

/**
 * Process the cssText, to remove potential invalid items when used as an inline
 * style sheet instead of a linked resources.
 *
 * Replace resources like url(image.png) with del_url(image.png),
 * to avoid the relative path problem.
 */
function processCSSText(text) {
  var URL_REGEXP = /\burl(\s*)?\(/gi;
  text = text.replace(URL_REGEXP, 'del_url$1(');
  return text;
}

function isAllRequestFinished(requestResultMap) {
  for (var url in requestResultMap) {
    if (!requestResultMap[url].finished)
      return false;
  }
  return true;
}

function crossOrginOperationFinished(tabId) {
  var data = {};
  var requestInfo = requestInfoMap[tabId];
  if (requestInfo) {
    for (var url in requestInfo.requestResultMap) {
      var requestResult = requestInfo.requestResultMap[url];
      if (requestResult.finished && requestResult.cssText)
         data[url] = requestResult.cssText;
    }
  }
  // Removes the result immediately.
  abortXHR(tabId);
  chrome.tabs.sendRequest(tabId, {
    type: REQUEST_GET_CROSS_ORIGIN_CSS_FINISHED,
    data: data
  });
}

function abortXHR(tabId) {
  var requestInfo = requestInfoMap[tabId];
  if (!requestInfo)
    return;
  if (requestInfo.timerId)
    clearTimeout(requestInfo.timerId);
  for (var url in requestInfo.requestResultMap) {
    var requestResult = requestInfo.requestResultMap[url];
    if (requestResult.xhr)
      requestResult.xhr.abort();
  }
  delete requestInfoMap[tabId];
}

chrome.extension.onRequest.addListener(function(request, sender, sendResponse) {
  log('onRequest, request.type=' + request.type);
  var tabId = sender.tab.id;
  switch (request.type) {
    case REQUEST_SET_STATUS:
      setBrowserAction(request.status, tabId);
      break;
    case REQUEST_COMPATIBILITY_RESULT:
      addDetectedProblemToResult(tabId, request);
      break;
    case REQUEST_END_OF_DETECTION:
      stopAddingDetectedProblem(tabId, request.totalProblems);
      break;
    case REQUEST_PAGE_UNLOAD:
      delete detectionResults[tabId];
      abortXHR(tabId);
      setBrowserAction('normal', tabId);
      break;
    case REQUEST_GET_CROSS_ORIGIN_CSS:
      getCrossOriginCSS(tabId, request.urlMap);
      break;
    case REQUEST_GET_DISABLED_DETECTORS:
      sendResponse(getDisabledDetectors());
      break;
  }
});

// Delete tab's information when tab closed.
chrome.tabs.onRemoved.addListener(function(tabId) {
  delete detectionResults[tabId];
  abortXHR(tabId);
});

</script>
</head>
<body>
</body>
</html>
